-- TextFrames.lua
-- Patch: Big/Ultra CRIT = mêmes animations que les autres textes, sans outline spécial ni changement de couleur.
-- Ajout : ombres portées renforcées sur Big/Ultra CRIT + accent de couleur mid-anim.
-- + Icônes robustes (sans mask), ring visible, debug et fallbacks.
-- + Suffixe OH "(~100% OH)" rendu plus petit via un FontString séparé (f.sub), animé/teinté/alpha comme le texte principal.

local ADDON, DFFT = ...
if not DFFT then return end
DFFT.DB = DFFT.DB or {}
local DB = DFFT.DB

-- Expose un flag de capability
DFFT._supportsIconRing = true

-- === imports from Core ===
local rgb                 = DFFT.rgb or function(c) return c[1], c[2], c[3] end
local shortNumber         = DFFT.shortNumber
local shortNumberRounded  = DFFT.shortNumberRounded
local KNOWN_FONTS         = DFFT.KNOWN_FONTS
local SYMBOL_FONT         = DFFT.SYMBOL_FONT or (STANDARD_TEXT_FONT or "Fonts\\FRIZQT__.TTF")
local CRIT_GLYPH          = DFFT.CRIT_GLYPH or "*"

-- ================== Defaults (debug OFF) ==================
if DB.iconDebug == nil then DB.iconDebug = false end
local function IconDebugOn() return DB.iconDebug == true end

-- Taille relative du suffixe (ex: 0.5 = deux fois plus petit)
DB.suffixScale = DB.suffixScale or 0.50
DB.suffixGapX  = (DB.suffixGapX == nil) and 2 or DB.suffixGapX
DB.suffixGapY  = DB.suffixGapY or 0

-- ================== Crit color fade toggle ==================
local CRIT_FADE_ON = not not (DB.enableCritColorFade or DB.critColorFadeEnabled)
function DFFT.SetCritColorFadeEnabled(on)
  on = not not on
  DB.enableCritColorFade  = on
  DB.critColorFadeEnabled = on
  DB.disableCritColorFade = not on
  CRIT_FADE_ON = on
end

-- ================== Font helpers (LSM-aware) ==================
local function pathForFont(name)
  if type(DFFT.FetchFontPath) == "function" then
    return DFFT.FetchFontPath(name)
  end
  return KNOWN_FONTS and (KNOWN_FONTS[name or ""] or KNOWN_FONTS["Teko Bold"]) or (STANDARD_TEXT_FONT or "Fonts\\FRIZQT__.TTF")
end

local function ensureFont(fs, px, outline)
  local fontPath = pathForFont(DB.fontName)
  local ok = fs:SetFont(fontPath, px or 30, outline or (DB.fontOutline or "OUTLINE"))
  if not ok then
    fs:SetFont(STANDARD_TEXT_FONT or "Fonts\\FRIZQT__.TTF", px or 30, outline or (DB.fontOutline or "OUTLINE"))
  end
end

function DFFT.refreshActiveFonts()
  for _,f in ipairs(DFFT._frames or {}) do
    if f and f.fs then ensureFont(f.fs, 30, DB.fontOutline or "OUTLINE") end
    if f and f.sub then ensureFont(f.sub, 30, DB.fontOutline or "OUTLINE") end
    if f and f.critFS then for _,shadow in ipairs(f.critFS) do ensureFont(shadow, 30, DB.fontOutline or "OUTLINE") end end
    if f and f.sym then f.sym:SetFont(SYMBOL_FONT, 28, DB.fontOutline or "OUTLINE") end
  end
end

-- ================== Math & color helpers ==================
local function clamp(x,a,b) if x<a then return a elseif x>b then return b end return x end
local function mix(a,b,t) if t<0 then t=0 elseif t>1 then t=1 end return a + (b-a)*t end
local function mapLinear(x,inMin,inMax,outMin,outMax)
  if inMax <= inMin then return outMax end
  local t=(x-inMin)/(inMax-inMin); if t<0 then t=0 elseif t>1 then t=1 end
  return outMin + (outMax - outMin) * t
end

-- Non-target alpha
local function computeNonTargetAlpha(amount)
  local smallMax  = DB.smallMax or 200
  local vbigMin   = math.max(DB.veryBigMin or 20000, (DB.bigMin or 5000) + 1)
  local a = mapLinear(amount or 0, smallMax, vbigMin, 0.40, 1.00)
  local cap = math.max(0.30, math.min(1.00, DB.nonTargetAlpha or 1.00))
  return math.max(0.40, math.min(cap, a))
end

-- -------- RGB <-> HSV util --------
local function rgb_to_hsv(r,g,b)
  local maxc = math.max(r,g,b); local minc = math.min(r,g,b)
  local v = maxc
  local d = maxc - minc
  if maxc == 0 then return 0, 0, 0 end
  local s = (maxc == 0) and 0 or (d / maxc)
  local h = 0
  if d ~= 0 then
    if maxc == r then h = (g - b) / d % 6
    elseif maxc == g then h = (b - r) / d + 2
    else h = (r - g) / d + 4 end
    h = h / 6
  end
  if h < 0 then h = h + 1 end
  return h, s, v
end
local function hsv_to_rgb(h,s,v)
  h = (h or 0) % 1; s = clamp(s or 0, 0, 1); v = clamp(v or 0, 0, 1)
  local i = math.floor(h*6)
  local f = h*6 - i
  local p = v * (1 - s)
  local q = v * (1 - f*s)
  local t = v * (1 - (1 - f)*s)
  local m = i % 6
  if m == 0 then return v, t, p
  elseif m == 1 then return q, v, p
  elseif m == 2 then return p, v, t
  elseif m == 3 then return p, q, v
  elseif m == 4 then return t, p, v
  else return v, p, q end
end

-- Accent critique
local function computeCritAccentColor(r,g,b)
  local h,s,v = rgb_to_hsv(r,g,b)
  if h > 0.12 and h < 0.20 then
    h = clamp(h - 0.06, 0, 1); s = clamp(s + 0.15, 0, 1); v = clamp(v - 0.08, 0, 1)
  else
    s = clamp(s + 0.25, 0, 1); v = clamp(v - 0.12, 0, 1)
    if v > 0.85 and s < 0.25 then h = (h + 0.98) % 1 end
  end
  local rr,gg,bb = hsv_to_rgb(h,s,v)
  return math.max(rr,0.02), math.max(gg,0.02), math.max(bb,0.02)
end

-- ================== Frame pool ==================
local frames, activeCount = DFFT._frames or {}, 0
DFFT._frames = frames
local OUTLINE_OFFSETS = { {-2,0},{2,0},{0,-2},{0,2},{-1,-1},{-1,1},{1,-1},{1,1} }

-- ====== Ombres portées Big/Ultra ======
local function _shadowToggleEnabled() return DB.bigCritShadowEnabled ~= false end
local function _setDefaultSoftShadow(f)
  if not f or not f.fs then return end
  f.fs:SetShadowColor(0,0,0,0.60); f.fs:SetShadowOffset(1,-1)
  if f.sym then f.sym:SetShadowColor(0,0,0,0.60); f.sym:SetShadowOffset(1,-1) end
  if f.sub then f.sub:SetShadowColor(0,0,0,0.55); f.sub:SetShadowOffset(1,-1) end
  f.hasCritShadow = false
end
local function _setStrongCritShadow(f)
  if not f or not f.fs then return end
  local ca = DB.critShadowAlpha or 0.95
  local curScale = (f.fs.GetScale and f.fs:GetScale()) or 1
  local base = f.critShadowBase or DB.critShadowOffset or 2
  local off = math.max(1, math.floor(base * curScale + 0.5))
  f.fs:SetShadowColor(0,0,0, ca); f.fs:SetShadowOffset(off, -off)
  if f.sym then
    local soff = math.max(1, off - 1)
    f.sym:SetShadowColor(0,0,0, ca); f.sym:SetShadowOffset(soff, -soff)
  end
  if f.sub then
    local soff = math.max(1, off - 1)
    f.sub:SetShadowColor(0,0,0, ca*0.85); f.sub:SetShadowOffset(soff, -soff)
  end
  f.hasCritShadow = true
end
local function _applyShadowForFrame(f)
  if _shadowToggleEnabled() and f._isCritCurrent and f.isBigTier then _setStrongCritShadow(f) else _setDefaultSoftShadow(f) end
end
function DFFT.SetBigCritShadowEnabled(on)
  DB.bigCritShadowEnabled = (on ~= false)
  for _,fr in ipairs(frames) do if fr and fr:IsShown() then _applyShadowForFrame(fr) end end
end

-- ================== Icône robuste (sans mask) ==================
local RING_TEXTURE = "Interface\\Buttons\\UI-Quickslot2"

local function fontSizeForTier(tier)
  local sizes = DB.fontSizes or { small=16, medium=20, big=24, vbig=28 }
  return sizes[tier] or 18
end
local function iconSizeForTier(tier)
  local base = fontSizeForTier(tier)
  local factor = DB.iconSizeFactor or 1.0
  return math.floor(base * (0.95 * factor))
end

local function ensureIconTextures(f)
  if f.icon and f.iconRing then return end
  local tex = f:CreateTexture(nil, "OVERLAY")
  tex:SetDrawLayer("OVERLAY", 1)
  tex:SetPoint("CENTER")
  f.icon = tex
  local ring = f:CreateTexture(nil, "OVERLAY")
  ring:SetDrawLayer("OVERLAY", 2)
  ring:SetTexture(RING_TEXTURE)
  ring:SetBlendMode("ADD")
  ring:SetPoint("CENTER", tex, "CENTER", 0, 0)
  f.iconRing = ring
end

local function applyIcon(f, tier, opts)
  local iconVal = opts and (opts.icon or opts.iconTexture or opts.texture)
  local allow  = (DB.showIcons ~= false) or (opts and opts.force)
  local show   = (iconVal ~= nil) and allow
  if IconDebugOn() and show then
    print("|cFF77FF77SCT Icon:|r applyIcon show=",show," icon=",tostring(iconVal))
  end
  if not show then
    if f.icon then f.icon:Hide() end
    if f.iconRing then f.iconRing:Hide() end
    return
  end
  ensureIconTextures(f)
  local size = iconSizeForTier(tier)
  local gap  = DB.iconGap or 6
  local pos  = DB.iconPosition or "left" -- "left"|"right"
  local r,g,b = f.fs:GetTextColor()
  f.icon:ClearAllPoints()
  f.icon:SetSize(size, size)
  if DB.iconForceSquare then
    f.icon:SetTexture(nil); f.icon:SetColorTexture(1, 0, 1, 0.85)
  else
    f.icon:SetColorTexture(0,0,0,0)
    f.icon:SetTexture(iconVal)
  end
  DFFT._lastIconApplied = iconVal
  f.icon:Show()
  local ringSize = size * (DB.iconRingScale or 1.18)
  f.iconRing:ClearAllPoints()
  f.iconRing:SetPoint("CENTER", f.icon, "CENTER", 0, 0)
  f.iconRing:SetSize(ringSize, ringSize)
  f.iconRing:SetVertexColor(r or 1, g or 1, b or 1, DB.iconRingAlpha or 0.90)
  f.iconRing:Show()
  local yOff = (DB.iconYOffset ~= nil) and DB.iconYOffset or 6
  f._iconYOff = yOff
  f.fs:ClearAllPoints()
  f.icon:ClearAllPoints()
  if pos == "right" then
    f.fs:SetPoint("RIGHT", f, "CENTER", -gap, 0)
    f.icon:SetPoint("LEFT", f.fs, "RIGHT", gap, yOff)
  else
    f.icon:SetPoint("RIGHT", f, "CENTER", -gap, yOff)
    f.fs:SetPoint("LEFT", f.icon, "RIGHT", gap, 0)
  end
  if IconDebugOn() then
    local tw,th = f.icon:GetSize()
    print("|cFF77FF77SCT Icon:|r SHOWN size=",tw,"x",th," ring=",ringSize)
  end
end

-- ================== Construction des frames ==================
local function createTextFrame()
  local f = CreateFrame("Frame", nil, UIParent)
  f:SetSize(10,10)

  local fs = f:CreateFontString(nil, "OVERLAY", "SystemFont_Shadow_Med1")
  f.fs = fs; ensureFont(fs, 30, DB.fontOutline or "OUTLINE")
  fs:SetPoint("CENTER", f, "CENTER", 0, 0)
  fs:SetText("12345")

  -- Suffixe (ex : (~100% OH)) en plus petit
  local sub = f:CreateFontString(nil, "OVERLAY", "SystemFont_Shadow_Med1")
  f.sub = sub; ensureFont(sub, 30, DB.fontOutline or "OUTLINE")
  sub:SetPoint("LEFT", fs, "RIGHT", DB.suffixGapX or 2, DB.suffixGapY or 0)
  sub:SetText(""); sub:Hide()

  local sym = f:CreateFontString(nil, "OVERLAY", "SystemFont_Shadow_Med1")
  f.sym = sym; sym:SetFont(SYMBOL_FONT, 28, DB.fontOutline or "OUTLINE"); sym:SetText(CRIT_GLYPH)
  sym:SetPoint("RIGHT", fs, "LEFT", -4, 0); sym:Hide()

  -- couches “critFS” conservées mais cachées
  f.critFS = {}
  for _,off in ipairs(OUTLINE_OFFSETS) do
    local s = f:CreateFontString(nil, "BACKGROUND", "SystemFont_Shadow_Med1")
    s:SetPoint("CENTER", f, "CENTER", off[1], off[2])
    s:SetText("12345"); s:SetTextColor(0,0,0,1); s:SetAlpha(0.95)
    ensureFont(s, 30, DB.fontOutline or "OUTLINE")
    s:Hide()
    table.insert(f.critFS, s)
  end

  local ag = f:CreateAnimationGroup()
  local fade = ag:CreateAnimation("Alpha")
  fade:SetFromAlpha(1.0); fade:SetToAlpha(0.0); fade:SetDuration(1.4)
  f.ag, f.fade = ag, fade

  f.fxElapsed = 0
  f.fxDur     = 1.6
  f.fxStartScale = 0.52
  f.fxEndScale   = 0.86
  f.fxInTime     = 0.22
  f.fxOutTime    = 0.22
  f.fxPulseAmp   = 0.03
  f.fxPulseFreq  = 6.0

  f.shakeAmp  = 0.0
  f.shakeFreq = 6.0

  f.onPlate = false
  f.isBigTier = false
  f.ownerIsTarget = true
  f.sideMode = false
  f.sideDir = nil

  f.bPoint, f.bRelTo, f.bRelPoint, f.bx, f.by = "CENTER", UIParent, "CENTER", 0, 0
  f.moveDX, f.moveDY = 0, 0

  f.scaleMulExtra = 1
  f.colorFadeActive = false
  f.isHealLike = false
  f.hasCritShadow = false
  f._isCritCurrent = false
  f.critShadowBase = 2

  f.accentEnabled = false
  f.accentStartFrac = 0.45
  f.accentEndFrac   = 0.75
  f.baseR, f.baseG, f.baseB = 1,1,1
  f.accR,  f.accG,  f.accB  = 1,1,1

  f:SetScript("OnShow", function(self) self.fxElapsed = 0 end)
  f:SetScript("OnHide", function(self)
    self.fs:ClearAllPoints(); self.fs:SetPoint("CENTER", self, "CENTER", 0, 0)
    self.sym:Hide(); self.sym:ClearAllPoints(); self.sym:SetPoint("RIGHT", self.fs, "LEFT", -4, 0)
    if self.sub then self.sub:Hide(); self.sub:SetText(""); self.sub:ClearAllPoints(); self.sub:SetPoint("LEFT", self.fs, "RIGHT", DB.suffixGapX or 2, DB.suffixGapY or 0) end
    for _,s in ipairs(self.critFS) do s:Hide(); s:ClearAllPoints(); s:SetPoint("CENTER", self, "CENTER", 0,0) end
    self:SetScript("OnUpdate", nil)
    if self.icon then self.icon:Hide(); self.icon:ClearAllPoints() end
    if self.iconRing then self.iconRing:Hide(); self.iconRing:ClearAllPoints() end
    self.fs:SetShadowColor(0,0,0,0.60); self.fs:SetShadowOffset(1,-1)
    self.sym:SetShadowColor(0,0,0,0.60); self.sym:SetShadowOffset(1,-1)
    if self.sub then self.sub:SetShadowColor(0,0,0,0.55); self.sub:SetShadowOffset(1,-1) end
    self.baseR, self.baseG, self.baseB = 1,1,1
    self.accR,  self.accG,  self.accB  = 1,1,1
    self.accentEnabled = false
    self.scaleMulExtra = 1
    self.colorFadeActive = false
    self.isHealLike = false
    self.hasCritShadow = false
    self._isCritCurrent = false
    self.critShadowBase = 2
    self._iconYOff = nil
  end)

  return f
end

local function acquireFrame()
  for _,f in ipairs(frames) do if not f:IsShown() then return f end end
  local f = createTextFrame(); frames[#frames+1] = f; return f
end

-- ================== Nameplate helper ==================
local function anchorToNameplate(unit)
  if not C_NamePlate or not C_NamePlate.GetNamePlateForUnit then return nil end
  if not UnitExists(unit) then return nil end
  local plate = C_NamePlate.GetNamePlateForUnit(unit)
  if plate and plate:IsShown() and (not plate.IsForbidden or not plate:IsForbidden()) then return plate end
  return nil
end

-- ================== Placement ==================
local function placeInitial(f, tier, jx, jy, ownerIsTarget, amount)
  local cfgMap = {
    small  = { offX = DB.smallTargetOffsetX,  offY = DB.smallTargetOffsetY,  ax = DB.smallAnchorX,  ay = DB.smallAnchorY,  diag = DB.diagHSmall  },
    medium = { offX = DB.mediumTargetOffsetX, offY = DB.mediumTargetOffsetY, ax = DB.mediumAnchorX, ay = DB.mediumAnchorY, diag = DB.diagHMedium },
    big    = { offX = DB.bigTargetOffsetX,    offY = DB.bigTargetOffsetY,    ax = DB.bigAnchorX,    ay = DB.bigAnchorY,    diag = DB.diagHBig    },
    vbig   = { offX = DB.vbigTargetOffsetX,   offY = DB.vbigTargetOffsetY,   ax = DB.vbigAnchorX,   ay = DB.vbigAnchorY,   diag = DB.diagHUltra  },
  }
  local cfg = cfgMap[tier]

  -- ✅ FIX : si globalAnchorToTarget est activé, on ANCRE sur la CIBLE pour
  -- la cible ET les non-cibles. Avant, c'était conditionné par ownerIsTarget.
  local useTargetAnchor = (DB.globalAnchorToTarget == true)

  local alphaMul, scaleMul = 1, 1
  local diagX = cfg.diag or 0
  local addX, addY = 0, 0

  if not ownerIsTarget then
    alphaMul = computeNonTargetAlpha(amount or 0)
    diagX = math.abs(cfg.diag or 0)
    if useTargetAnchor then
      addX = DB.nonTargetOffsetX or 0
      addY = DB.nonTargetOffsetY or 0
    end
    scaleMul = 1
  end

  f.onPlate = false
  f:ClearAllPoints()

  local ox = (jx or 0) + addX
  local oy = (jy or 0) + addY

  local unit = useTargetAnchor and "target" or "player"
  local plate = anchorToNameplate(unit)
  if plate then
    local yOff = (cfg.offY or 0) + oy - ((DB.targetHeightComp or 0.40) * 40)
    f:SetPoint("TOP", plate, "TOP", (cfg.offX or 0) + ox, yOff)
    f.onPlate = true
    f.bPoint, f.bRelTo, f.bRelPoint, f.bx, f.by = "TOP", plate, "TOP", (cfg.offX or 0) + ox, yOff
  else
    f:SetPoint("CENTER", UIParent, "CENTER", (cfg.ax or 0) + ox, (cfg.ay or 0) + oy)
    f.bPoint, f.bRelTo, f.bRelPoint, f.bx, f.by = "CENTER", UIParent, "CENTER", (cfg.ax or 0) + ox, (cfg.ay or 0) + oy
  end

  f.moveDX, f.moveDY = diagX or 0, DB.riseY or 80
  return alphaMul, scaleMul, plate
end

-- ================== Side-fall decision ==================
local function decideSideFallForThisShot(f, tier)
  local side = false
  if f.onPlate then
    side = (DB.sideFallEnabled ~= false)
  else
    if DB.sideFallEnabled ~= false then
      local myTop = f:GetTop()
      local screenTop = UIParent:GetTop() or 0
      local tooHigh = (myTop and screenTop and (myTop >= (screenTop * (DB.sideFallTopRatio or 0.92)))) or false
      side = tooHigh
    end
  end
  local dir
  if f.ownerIsTarget == false then
    dir = "R"
  else
    dir = (tier == "vbig") and "R" or "L"
  end
  return side, dir
end

-- ================== Anti-overlap helpers ==================
local function pushApartByOffsets(f, padY, shrinkIfBumped, filterFn)
  local shrunk = false
  local passes = 3
  for _=1, passes do
    local bumped = false
    for _, other in ipairs(frames) do
      if other ~= f and other:IsShown() and other.onPlate and other.bRelTo == f.bRelTo then
        if not filterFn or filterFn(other) then
          local dx = math.abs((f.bx or 0) - (other.bx or 0))
          local dy = math.abs((f.by or 0) - (other.by or 0))
          if dx < 140 and dy < padY then
            local delta = (padY - dy)
            f.by = f.by + delta
            f:ClearAllPoints()
            f:SetPoint(f.bPoint, f.bRelTo, f.bRelPoint, f.bx, f.by)
            bumped = true
            shrunk = shrunk or shrinkIfBumped
          end
        end
      end
    end
    if not bumped then break end
  end
  return shrunk
end

local function resolveOverlapForBigs(f)
  if not DB.antiOverlap then return end
  if not f.isBigTier then return end
  local pad = DB.antiOverlapPadY or 26
  if f.onPlate then
    pushApartByOffsets(f, pad, false, function(other) return other.isBigTier end)
    return
  end
  local fx, fy = f:GetCenter()
  if not fx or not fy then return end
  for _, other in ipairs(frames) do
    if other ~= f and other:IsShown() and other.isBigTier and not other.onPlate then
      local ox, oy = other:GetCenter()
      if ox and oy then
        local dx, dy = math.abs(fx - ox), math.abs(fy - oy)
        if dx < 140 and dy < pad then
          f.by = f.by + (pad - dy)
          f:ClearAllPoints()
          f:SetPoint(f.bPoint, f.bRelTo, f.bRelPoint, f.bx, f.by)
          fx, fy = f:GetCenter(); if not fx then return end
        end
      end
    end
  end
end

local function resolveOverlapForNonTargets(f)
  if f.ownerIsTarget ~= false then return end
  if DB.nonTargetAntiOverlap == false then return end
  local padY   = DB.nonTargetPadY or 18
  if DB.globalAnchorToTarget then padY = math.max(1, math.floor(padY * 0.60)) end
  if f.onPlate then
    local shrunk = pushApartByOffsets(f, padY, true, function(other) return other.ownerIsTarget == false end)
    f.scaleMulExtra = shrunk and (DB.nonTargetScaleOverlap or 0.70) or 1
    return
  end
  local shrunk = false
  local fx, fy = f:GetCenter()
  if not fx or not fy then return end
  for _ = 1, 3 do
    local bumped = false
    for _, other in ipairs(frames) do
      if other ~= f and other:IsShown() and other.ownerIsTarget == false and not other.onPlate then
        local ox, oy = other:GetCenter()
        if ox and oy then
          local dx, dy = math.abs(fx - ox), math.abs(fy - oy)
          if dx < 140 and dy < padY then
            local delta = (padY - dy)
            f.by = f.by + delta
            f:ClearAllPoints()
            f:SetPoint(f.bPoint, f.bRelTo, f.bRelPoint, f.bx, f.by)
            fx, fy = f:GetCenter()
            bumped = true
            shrunk = true
          end
        end
      end
    end
    if not bumped then break end
  end
  f.scaleMulExtra = shrunk and (DB.nonTargetScaleOverlap or 0.70) or 1
end

-- ================== Side-fall Bezier params ==================
local function sideParamsFor(tier)
  local X = (tier=="small" and 220) or (tier=="medium" and 240) or (tier=="big" and 260) or 280
  local Y = (tier=="small" and 160) or (tier=="medium" and 170) or (tier=="big" and 180) or 190
  X = DB.sideArcX or X; Y = DB.sideArcY or Y
  return X, Y
end

-- ================== Heals detection (approx) ==================
local function collectHealPalette()
  local P = {}
  local H = DB.healer or {}
  local C = H.colors or {}
  local t, n = C.target or {}, C.nontarget or {}
  local function push(col) if col and col[1] then P[#P+1] = {col[1], col[2], col[3]} end end
  push(H.healSmallColor);     push(H.healMediumColor);     push(H.healBigColor);     push(H.healVeryBigColor)
  push(H.nonTargetHealSmallColor); push(H.nonTargetHealMediumColor); push(H.nonTargetHealBigColor); push(H.nonTargetHealVeryBigColor)
  push(C.absorb); if H.absorbs and H.absorbs.color then push(H.absorbs.color) end
  push(t.small); push(t.medium); push(t.big); push(t.vbig)
  push(n.small); push(n.medium); push(n.big); push(n.vbig)
  return P
end
local function isLikelyHealColor(r,g,b)
  local P = collectHealPalette()
  local eps = DB.healMatchEps or 0.18
  for i=1,#P do local q = P[i]; if q and math.sqrt((r-q[1])^2 + (g-q[2])^2 + (b-q[3])^2) <= eps then return true end end
  return false
end

-- Helper: split main text and OH suffix "(...OH)"
local function splitMainAndOHSuffix(t)
  if not t or t == "" then return "", nil end
  -- On ne sépare que si le suffixe est tout à la fin, entre parenthèses, et contient "OH"
  local suf = t:match("%s*(%b())%s*$")
  if suf and suf:find("OH") then
    local main = t:gsub("%s*%b()%s*$", "") -- retire la dernière paire de ()
    return main, suf
  end
  return t, nil
end

-- ================== Show number ==================
-- amount,text,tier,ownerIsTarget,isCrit, sideDirForced, [opts]
function DFFT.showNumber(amount, text, tier, ownerIsTarget, isCrit, sideDirForced, opts)
  if type(sideDirForced) == "table" and opts == nil then
    opts = sideDirForced
    sideDirForced = nil
  end
  if activeCount >= (DB.maxActive or 20) then return end

  if (not opts or not opts.icon) and DFFT and DFFT._iconForNext then
    opts = opts or {}
    opts.icon = DFFT._iconForNext
    DFFT._iconForNext = nil
    if IconDebugOn() then
      print("|cFF77FF77SCT Icon:|r recovered from stash ->", tostring(opts.icon))
    end
  else
    if DFFT then DFFT._iconForNext = nil end
  end

  DFFT._lastSawOpts = opts

  local f = acquireFrame()
  local fs = f.fs
  ensureFont(fs, 30, DB.fontOutline or "OUTLINE")

  f.ownerIsTarget = ownerIsTarget and true or false
  f.isBigTier = (tier == "big" or tier == "vbig")
  f.sideMode = false
  f.sideDir = nil
  f.scaleMulExtra = 1
  f.colorFadeActive = false
  f.isHealLike = false
  f.hasCritShadow = false
  f._isCritCurrent = not not isCrit
  f.critShadowBase = DB.critShadowOffset or 2

  -- couleurs / tailles
  local scale, dur, alpha, color
  if tier == "small" then
    color = DB.smallColor or {1,1,1}
    scale = DB.smallScale or 0.90; dur = DB.durationSmall or 1.2; alpha = DB.smallAlpha or 0.95
  elseif tier == "medium" then
    color = DB.mediumColor or {1,1,1}
    scale = DB.mediumScale or 1.00; dur = DB.durationMedium or 1.5; alpha = DB.mediumAlpha or 1.00
  else
    color = (tier == "vbig") and (DB.veryBigColor or {1,1,1}) or (DB.bigColor or {1,1,1})
    alpha = (tier == "vbig") and (DB.veryBigAlpha or 1.0) or (DB.bigAlpha or 1.0)
    dur   = DB.durationBig or 1.8
    local bigMin = math.max(DB.bigMin or 5000, (DB.smallMax or 200) + 1)
    scale = mapLinear(amount or 0, bigMin, DB.scaleCapAt or 120000, DB.bigScaleBase or 1.15, DB.bigScaleMax or 1.55)
  end

  if not f.ownerIsTarget then color = DB.nonTargetColor or color end

  local baseR, baseG, baseB = rgb(color)
  fs:SetTextColor(baseR, baseG, baseB)

  -- Split main/suffix for OH "(...OH)"
  local main, suf = splitMainAndOHSuffix(text or "")
  fs:SetText(main or "")

  if f.sub then
    if suf then
      f.sub:SetText(suf or "")
      f.sub:Show()
    else
      f.sub:SetText("")
      f.sub:Hide()
    end
  end

  if isCrit and (DB.showCritSymbol ~= false) then
    f.sym:SetFont(SYMBOL_FONT, 28, DB.fontOutline or "OUTLINE")
    f.sym:SetTextColor(baseR, baseG, baseB)
    f.sym:Show()
  else
    f.sym:Hide()
  end

  -- Icône (robuste, sans mask)
  applyIcon(f, tier, opts or nil)

  -- Ombres
  _setDefaultSoftShadow(f)
  _applyShadowForFrame(f)
  for _,s in ipairs(f.critFS) do s:Hide() end
  ensureFont(fs, 30, DB.fontOutline or "OUTLINE")
  if f.sub then ensureFont(f.sub, 30, DB.fontOutline or "OUTLINE") end

  f.isHealLike = isLikelyHealColor(baseR, baseG, baseB)

  f.colorFadeActive = false
  f.baseR, f.baseG, f.baseB = baseR, baseG, baseB
  if isCrit and f.isBigTier then
    f.accR, f.accG, f.accB = computeCritAccentColor(baseR, baseG, baseB)
    f.accentEnabled = true
  else
    f.accR, f.accG, f.accB = baseR, baseG, baseB
    f.accentEnabled = false
  end

  local strata = "HIGH"
  local level  = (tier=="vbig" and 410) or (tier=="big" and 360) or (tier=="medium" and 300) or 250
  f:SetFrameStrata(strata); f:SetFrameLevel(level)

  local jx = math.random(-(DB.jitterX or 2), (DB.jitterX or 2))
  local jy = math.random(-(DB.jitterY or 2), (DB.jitterY or 2))

  local alphaMul, scaleMul = placeInitial(f, (tier=="vbig") and "vbig" or tier, jx, jy, f.ownerIsTarget, amount)

  if not (DB.globalAnchorToTarget) then
    local spread = DB.playerSideSpread or (DFFT.defaults and DFFT.defaults.playerSideSpread) or 0
    if spread ~= 0 then
      local dir = (not f.ownerIsTarget) and 1 or ((tier == "vbig") and 1 or -1)
      f.bx = (f.bx or 0) + dir * spread
      f:ClearAllPoints(); f:SetPoint(f.bPoint, f.bRelTo, f.bRelPoint, f.bx, f.by)
    end
    if not f.ownerIsTarget then
      local extra = DB.nonTargetPlayerExtraX or 40
      if extra ~= 0 then
        f.bx = (f.bx or 0) + extra
        f:ClearAllPoints(); f:SetPoint(f.bPoint, f.bRelTo, f.bRelPoint, f.bx, f.by)
      end
    end
  end

  resolveOverlapForBigs(f)
  resolveOverlapForNonTargets(f)

  local scaleExtra = 1
  if not f.ownerIsTarget then
    if DB.globalAnchorToTarget then
      if tier == "small" or tier == "medium" then scaleExtra = 0.85 else scaleExtra = 0.70 end
    else
      if tier == "big" or tier == "vbig" then scaleExtra = 0.80 end
    end
  end

  local baseScale = (scale or 1) * (scaleMul or 1) * (f.scaleMulExtra or 1) * scaleExtra
  local baseAlpha = (alpha or 1) * (alphaMul or 1)

  -- Optionnel : si un wrapper passe opts.selfHeal=true, force alpha à 1 pour les heals (demande utilisateur)
  if f.isHealLike and opts and opts.selfHeal then
    baseAlpha = 1.0
  end

  local zoomOn = not (DB.enableZoom == false)

  local function updateCritShadow(self, glyphScale)
    if not self.hasCritShadow then return end
    local off = math.max(1, math.floor((self.critShadowBase or 2) * glyphScale + 0.5))
    self.fs:SetShadowOffset(off, -off)
    self.sym:SetShadowOffset(math.max(1, off - 1), -math.max(1, off - 1))
    if self.sub and self.sub:IsShown() then
      local soff = math.max(1, off - 1)
      self.sub:SetShadowOffset(soff, -soff)
    end
  end

  local function updateCritAccent(self, u)
    if not self.accentEnabled then return end
    local s, e = self.accentStartFrac or 0.45, self.accentEndFrac or 0.75
    local r,g,b
    if u <= s then
      r,g,b = self.baseR, self.baseG, self.baseB
    elseif u >= e then
      r,g,b = self.accR, self.accG, self.accB
    else
      local t = (u - s) / (e - s)
      r = mix(self.baseR, self.accR, t)
      g = mix(self.baseG, self.accG, t)
      b = mix(self.baseB, self.accB, t)
    end
    self.fs:SetTextColor(r,g,b)
    self.sym:SetTextColor(r,g,b)
    if self.sub and self.sub:IsShown() then self.sub:SetTextColor(r,g,b) end
    if self.iconRing and self.iconRing:IsShown() then
      self.iconRing:SetVertexColor(r,g,b, DB.iconRingAlpha or 0.90)
    end
  end

  local function applyScaleAndAccent(self, t)
    local s
    if not zoomOn then
      s = 1
    elseif t <= self.fxInTime then
      local ui = t / self.fxInTime; if ui < 0 then ui = 0 elseif ui > 1 then ui = 1 end
      s = self.fxStartScale + (1 - self.fxStartScale) * (1 - (1-ui)^3)
    elseif t >= self.fxDur - self.fxOutTime then
      local uo = (t - (self.fxDur - self.fxOutTime)) / self.fxOutTime; if uo < 0 then uo = 0 elseif uo > 1 then uo = 1 end
      s = 1 + (self.fxEndScale - 1) * (uo^3)
    else
      s = 1 + self.fxPulseAmp * math.sin((t - self.fxInTime) * 6.28318 * self.fxPulseFreq)
    end
    local glyphScale = baseScale * s
    self.fs:SetScale(glyphScale)
    self.sym:SetScale(glyphScale)
    if self.sub and self.sub:IsShown() then
      self.sub:SetScale(glyphScale * (DB.suffixScale or 0.50))
    end
    if self.icon then self.icon:SetScale(glyphScale) end
    if self.iconRing then self.iconRing:SetScale(glyphScale) end
    for _,shadow in ipairs(self.critFS) do shadow:SetScale(glyphScale) end
    updateCritShadow(self, glyphScale)

    local u = (t <= 0 and 0) or (t >= self.fxDur and 1) or (t/self.fxDur)
    updateCritAccent(self, u)
  end

  -- Décision side/dir
  local side, dir = decideSideFallForThisShot(f, tier)
  if sideDirForced == "L" or sideDirForced == "R" then side, dir = true, sideDirForced end

  if side and dir then
    f.sideMode = true
    f.sideDir = dir
    local DX, DY = sideParamsFor(tier)
    if dir == "L" then DX = -DX end
    DY = -math.abs(DY)
    local p0x, p0y = 0, 0
    local p1x, p1y = DX * 0.35, DY * 0.05
    local p2x, p2y = DX * 0.70, DY * 0.85
    local p3x, p3y = DX, DY

    local durSide = math.max((DB.durationBig or 1.8) + 0.2, 1.4)
    f.fade:SetFromAlpha(baseAlpha); f.fade:SetToAlpha(0); f.fade:SetDuration(durSide)

    f.fxElapsed = 0
    f.fxDur     = durSide
    f.fxStartScale = zoomOn and 0.90 or 1.00
    f.fxEndScale   = zoomOn and 0.92 or 1.00
    f.fxInTime     = zoomOn and 0.12 or 0.00
    f.fxOutTime    = zoomOn and 0.18 or 0.00
    f.fxPulseAmp   = zoomOn and (f.isBigTier and 0.03 or 0.02) or 0.00
    f.fxPulseFreq  = 2.1
    f.shakeAmp     = 0

    local startBX, startBY = f.bx, f.by
    f:SetScript("OnUpdate", function(self, elapsed)
      self.fxElapsed = self.fxElapsed + elapsed
      local t  = self.fxElapsed
      local u  = (t <= 0 and 0) or (t >= self.fxDur and 1) or (t/self.fxDur)

      local uX = math.sqrt(u)
      local uY = u ^ 2.2

      local vX = 1 - uX
      local vY = 1 - uY
      local dx = vX*vX*vX*p0x + 3*vX*vX*uX*p1x + 3*vX*uX*uX*p2x + uX*uX*uX*p3x
      local dy = vY*vY*vY*p0y + 3*vY*vY*uY*p1y + 3*vY*uY*uY*p2y + uY*uY*uY*p3y

      self.bx = startBX + dx
      self.by = startBY + dy
      self:ClearAllPoints()
      self:SetPoint(self.bPoint, self.bRelTo, self.bRelPoint, self.bx, self.by)

      applyScaleAndAccent(self, t)
    end)
  else
    -- RISE standard
    f.fade:SetFromAlpha(baseAlpha); f.fade:SetToAlpha(0); f.fade:SetDuration(dur or 1.8)

    f.fxElapsed = 0
    f.fxDur     = (tier=="small" and (DB.durationSmall or 1.2)) or (tier=="medium" and (DB.durationMedium or 1.5)) or (DB.durationBig or 1.8)
    f.fxStartScale = zoomOn and 0.52 or 1.00
    f.fxEndScale   = zoomOn and 0.86 or 1.00
    f.fxInTime     = zoomOn and 0.22 or 0.00
    f.fxOutTime    = zoomOn and 0.22 or 0.00
    f.fxPulseAmp   = zoomOn and 0.03 or 0.00
    f.fxPulseFreq  = 2.4
    local shakeOn = not ((DB.shakeEnabled == false) or (DB.enableShake == false))
    f.shakeAmp     = shakeOn and (f.isBigTier and 0.6 or 0) or 0
    f.shakeFreq    = 6.0

    local uDX, uDY = f.moveDX, f.moveDY

    f:SetScript("OnUpdate", function(self, elapsed)
      self.fxElapsed = self.fxElapsed + elapsed
      local t  = self.fxElapsed

      local u = (t <= 0 and 0) or (t >= self.fxDur and 1) or (t/self.fxDur)
      local dx = uDX * u
      local dy = uDY * u

      self:ClearAllPoints()
      self:SetPoint(self.bPoint, self.bRelTo, self.bRelPoint, self.bx + dx, self.by + dy)

      -- shake offset appliqué au texte et aux icônes
      if self.shakeAmp and self.shakeAmp > 0 then
        local ox = math.sin(t * 6.28318 * self.shakeFreq) * self.shakeAmp
        local oy = math.cos(t * 6.28318 * (self.shakeFreq * 0.9)) * (self.shakeAmp * 0.85)

        local yOff = self._iconYOff or (DB.iconYOffset or 2)

        self.fs:ClearAllPoints()
        self.fs:SetPoint("CENTER", self, "CENTER", ox, oy)

        if self.sub and self.sub:IsShown() then
          self.sub:ClearAllPoints()
          self.sub:SetPoint("LEFT", self.fs, "RIGHT", DB.suffixGapX or 2, (DB.suffixGapY or 0))
        end

        self.sym:ClearAllPoints()
        self.sym:SetPoint("RIGHT", self.fs, "LEFT", -2, 0)

        if self.icon and self.icon:IsShown() then
          self.icon:ClearAllPoints()
          local gap = DB.iconGap or 6
          if (DB.iconPosition or "left") == "right" then
            self.icon:SetPoint("LEFT", self.fs, "RIGHT", gap + ox, oy + yOff)
          else
            self.icon:SetPoint("RIGHT", self, "CENTER", -gap + ox, oy + yOff)
            self.fs:ClearAllPoints()
            self.fs:SetPoint("LEFT", self.icon, "RIGHT", gap, 0)
            if self.sub and self.sub:IsShown() then
              self.sub:ClearAllPoints()
              self.sub:SetPoint("LEFT", self.fs, "RIGHT", DB.suffixGapX or 2, (DB.suffixGapY or 0))
            end
          end
          if self.iconRing then
            self.iconRing:ClearAllPoints()
            self.iconRing:SetPoint("CENTER", self.icon, "CENTER", 0, 0)
          end
        end

        for _,shadow in ipairs(self.critFS) do
          shadow:ClearAllPoints()
          shadow:SetPoint("CENTER", self, "CENTER", ox, oy)
        end
      end
      applyScaleAndAccent(self, t)
    end)
  end

  f:SetAlpha(baseAlpha)
  f.fs:SetAlpha(baseAlpha)
  f.sym:SetAlpha(baseAlpha)
  if f.sub and f.sub:IsShown() then f.sub:SetAlpha(baseAlpha) end
  if f.icon then f.icon:SetAlpha(baseAlpha) end
  if f.iconRing then f.iconRing:SetAlpha(baseAlpha) end

  -- Applique la taille du suffixe dès le début
  if f.sub and f.sub:IsShown() then
    f.sub:SetScale((DB.suffixScale or 0.50) * (f.fs:GetScale() or 1))
    f.sub:ClearAllPoints()
    f.sub:SetPoint("LEFT", f.fs, "RIGHT", DB.suffixGapX or 2, DB.suffixGapY or 0)
    f.sub:SetTextColor(baseR, baseG, baseB)
  end

  f:Show()
  activeCount = activeCount + 1
  f.ag:Stop(); f.ag:Play()
  f.ag:SetScript("OnFinished", function()
    f:Hide()
    activeCount = math.max(0, activeCount - 1)
    f:SetScript("OnUpdate", nil)
    f.fs:ClearAllPoints(); f.fs:SetPoint("CENTER", f, "CENTER", 0, 0)
    f.sym:Hide(); f.sym:ClearAllPoints(); f.sym:SetPoint("RIGHT", f.fs, "LEFT", -4, 0)
    if f.sub then f.sub:Hide(); f.sub:SetText(""); f.sub:ClearAllPoints(); f.sub:SetPoint("LEFT", f.fs, "RIGHT", DB.suffixGapX or 2, DB.suffixGapY or 0) end
    for _,s in ipairs(f.critFS) do s:Hide(); s:ClearAllPoints(); s:SetPoint("CENTER", f, "CENTER", 0,0) end
    if f.icon then f.icon:Hide(); f.icon:ClearAllPoints() end
    if f.iconRing then f.iconRing:Hide(); f.iconRing:ClearAllPoints() end
    f.fs:SetShadowColor(0,0,0,0.60); f.fs:SetShadowOffset(1,-1)
    f.sym:SetShadowColor(0,0,0,0.60); f.sym:SetShadowOffset(1,-1)
    if f.sub then f.sub:SetShadowColor(0,0,0,0.55); f.sub:SetShadowOffset(1,-1) end
    f.baseR, f.baseG, f.baseB = 1,1,1
    f.accR,  f.accG,  f.accB  = 1,1,1
    f.accentEnabled = false
    f.scaleMulExtra = 1
    f.colorFadeActive = false
    f.isHealLike = false
    f.hasCritShadow = false
    f._isCritCurrent = false
    f.critShadowBase = DB.critShadowOffset or 2
    f._iconYOff = nil
  end)
end

-- ================== Commandes de test locales ==================
SLASH_SCTRING1 = "/sctring"
SlashCmdList.SCTRING = function(msg)
  local prevDbg = DB.iconDebug
  DB.iconDebug = true
  local prevShow = DB.showIcons
  DB.showIcons = true

  local qmark = "Interface\\ICONS\\INV_Misc_QuestionMark"
  local fileID = 135846

  DFFT.showNumber(0, "12345 (~100% OH)", "medium", true, false, { icon = qmark, force = true })
  C_Timer.After(0.15, function()
    DFFT.showNumber(0, "67890 (~52% OH)", "medium", true, false, { icon = fileID, force = true })
  end)
  C_Timer.After(0.30, function()
    local prevSquare = DB.iconForceSquare
    DB.iconForceSquare = true
    DFFT.showNumber(0, "9001 (~12% OH)", "medium", true, false, { icon = fileID, force = true })
    DB.iconForceSquare = prevSquare

    DB.iconDebug = prevDbg
    DB.showIcons = prevShow
    print("|cFF77FF77SCT:|r /sctring terminé (debug restauré à "..tostring(prevDbg)..").")
  end)
  print("|cFF77FF77SCT:|r /sctring lancé (debug TEMPORAIREMENT ON le temps de la démo).")
end

SLASH_SCTRINGOFF1 = "/sctringoff"
SlashCmdList.SCTRINGOFF = function()
  DB.showIcons = not DB.showIcons
  print("|cFF77FF77SCT:|r showIcons =", tostring(DB.showIcons), " | iconDebug =", tostring(DB.iconDebug))
end

SLASH_SCTYOFF1 = "/sctyoff"
SlashCmdList.SCTYOFF = function(msg)
  local n = tonumber(msg or "")
  if not n then
    print("|cFF77FF77SCT:|r usage: /sctyoff <nombre>  (actuel: "..tostring(DB.iconYOffset or 2)..")")
    return
  end
  DB.iconYOffset = n
  print("|cFF77FF77SCT:|r iconYOffset = "..n.." (reprend sur les prochains textes)")
end
